Add vMCP core/server config derivation helpers#5513
Conversation
Phase 3.1 of the RFC THV-0076 config decomposition. The single in-memory server.Config is the last god-config that couples the domain core to the transport. Introduce two unexported helpers that map it onto the already- existing core.Config (Phase 1) and transport ServerConfig (Phase 2): - deriveServerConfig projects the transport-only fields plus the cross-cutting TelemetryProvider/AuditConfig (R3); the built health monitor (A2), shared backend registry, and assembled session-manager FactoryConfig are threaded in by the composition root since server.Config does not carry them. AuthzMiddleware is not projected — ServerConfig has no such field; authz moved to the core admission seam. - deriveCoreConfig assembles core.Config from cfg's cross-cutting fields (ServerName uses the raw cfg.Name for authz parity) plus the collaborators passed through rather than reached out of cfg. Both treat cfg as read-only (no defaulting write-back like server.New). This only adds the helpers and their tests; #5445 rewires server.New to call them through Serve. server.New and all serialized formats are unchanged. Closes #5444 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5513 +/- ##
==========================================
+ Coverage 69.68% 69.71% +0.02%
==========================================
Files 645 646 +1
Lines 65590 65633 +43
==========================================
+ Hits 45708 45757 +49
+ Misses 16535 16528 -7
- Partials 3347 3348 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-agent review summary
Recommendation: COMMENT — clean refactor, 0 blocking issues. All findings are LOW/INFO test-quality polish or forward-looking notes for #5445. (Posted as COMMENT rather than APPROVE because GitHub does not permit approving one's own PR.)
Phase 3.1 of the vMCP config decomposition (RFC THV-0076). Two unexported helpers — deriveServerConfig and deriveCoreConfig — map the legacy monolithic server.Config onto the new core.Config (domain) + ServerConfig (transport) pair, with 8 unit tests including reflection-based drift guards. Purely additive and in-memory-only: the helpers are not yet wired into server.New (#5445 will do that), so there is no observable behavior change.
Four specialist reviewers (Go conventions, architecture/anti-patterns, test coverage, security/authz) independently verified the mappings are complete — all 20 ServerConfig fields and all 11 core.Config fields are populated — that the cmp.Or defaulting matches server.New exactly (and intentionally leaves Port undefaulted), that cfg is treated as read-only (unlike server.New, which writes defaults back), and that Cedar authz parity is preserved by keying core.Config.ServerName on the raw VirtualMCPServer name rather than the synthetic toolhive-vmcp default. No correctness or security defects were found.
Consensus findings
| # | Severity | Category | Finding |
|---|---|---|---|
| 1 | LOW | Test | AuthMiddleware/RateLimitMiddleware are only NotNil-checked; a swap between the two same-typed fields would not be caught |
| 2 | LOW | Test | *MapsAllFields drift guard overstates its guarantee for cmp.Or-defaulted fields; skip-set / zero-valid Port handling under-documented; an idempotence parity test would help |
| 3 | INFO | Security | Forward-looking note for #5445: pass the raw cfg.Name to deriveCoreConfig (server.New mutates it in place) and preserve nil-Authz allow-all parity |
No HIGH or MEDIUM findings. The change satisfies every acceptance criterion in #5444.
Codex cross-review: skipped (codex CLI not installed).
🤖 Multi-agent review via /dev:pr-review
jerm-dro
left a comment
There was a problem hiding this comment.
suggestion: These are strictly options — the helpers are correct and the *MapsAllFields reflection guards are a solid safety net against silent drift. A couple of directions that could reduce the moving parts, take or leave:
Option 1 — pull defaulting strictly upstream. Right now the cmp.Or transport defaults live in three places: server.New, buildServeConfig, and deriveServerConfig. If the config is fully resolved once at the edge (cli/serve.go, where flags/CRD/YAML become config), then every projection downstream is pure pass-through — no cmp.Or, no duplicated "which fields get defaulted" list, no idempotent re-defaulting. The config types would then hold resolved values, which is easier to reason about than "defaulted depending on which constructor you came through."
Option 2 — reconsider whether the split needs separate types + derivation code. core.New and Serve are already separate functions, so a caller naturally only supplies what each needs — the boundary is enforced by what you pass each function. That raises the question of whether two config types (plus deriveCoreConfig/deriveServerConfig to convert between them, plus the server.Config → ServerConfig → Config round-trip) are buying enough over one shared config that both reference. Worth noting core.Config is ~8/11 injected collaborators and only 3 actual config values, so it's close to a collaborator bag core.New could take directly. If the split is still wanted, expressing it on the existing config (e.g. embedded sub-structs) would get the same separation without new conversion code or parallel transport configs that can drift.
Both are bigger than this diff and partly touch the earlier-phase types, so deferring the split entirely is also a reasonable call. I'll leave this up to you.
Addresses PR review (Option 1): the cmp.Or transport defaults previously lived in three projection functions (server.New, buildServeConfig, deriveServerConfig) with a duplicated "which fields" list, and server.New applied them by mutating the caller's Config in place. Consolidate to a single resolver, WithDefaults, and resolve once at the composition root (cli/serve.go): - WithDefaults is now the only place the transport default list lives. - deriveServerConfig and buildServeConfig become pure pass-through. - server.New calls WithDefaults on a COPY, so it no longer mutates the caller's Config. That non-mutation lets #5445 hand the raw, un-defaulted cfg.Name to the core for Cedar authz parity (it removes the carry-forward gotcha that #5445 would otherwise have to work around). server.New keeps defaulting defensively until #5445 reduces it to a Serve wrapper. Serve is now a pure pass-through, so its test fixtures resolve SessionTTL explicitly (a zero TTL fails session-storage construction). Defaulting behavior is covered by TestWithDefaults; the obsolete TestServeAppliesTransportDefaults / TestDeriveServerConfigAppliesTransportDefaults are removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@jerm-dro thanks for the review. Adopted Option 1 in 6d529d7. Transport defaulting now lives in a single resolver,
One pragmatic scoping choice: On Option 2 (whether the split needs separate types + derivation): fair point that |
"Option 1" referred to a PR-review discussion thread and is meaningless to future readers of the code. Reword the comments to keep the substance (transport defaults resolved once at the composition root via WithDefaults) without the review-specific label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WithDefaults is config plumbing of the same family as deriveServerConfig and deriveCoreConfig (resolve/decompose the legacy Config at the composition-root edge), so it belongs alongside them rather than in the already-oversized server.go. No behavior change; cmp moves with it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
The single in-memory
server.Configis the last god-config coupling the vMCP domain coreto the transport. Phase 1 (RFC THV-0076) extracted a stateless
core.Config, and Phase 2re-homed every transport concern under a
ServerConfigconsumed byServe— butserver.Newstill threads one monolithic config through both layers. This PR (Phase 3.1)adds the two derivation helpers that map
server.Configonto that core/transport pair,setting up the #5445 wrapper that will reduce
server.NewtoServe(ctx, core.New(deriveCoreConfig(cfg, …)), deriveServerConfig(cfg, …)).Two new unexported derivation helpers:
deriveServerConfigprojects the transport-only fields (Host,Port,EndpointPath,SessionTTL, the auth/rate-limit middleware handlers,AuthServer,status reporting, watcher, session storage) plus the cross-cutting
TelemetryProvider/AuditConfigonto a freshServerConfig, taking the pre-builthealth monitor, backend registry, and session-manager factory config as parameters. It
is a pure projection (see defaulting below).
deriveCoreConfigassembles thecore.Configfrom the cross-cutting fields plus thecollaborators (aggregator, router, backend client/registry, workflow defs, authz config,
elicitation requester, health status provider) the composition root builds.
Transport defaulting consolidated to the edge (review Option 1). Following review
feedback, the
cmp.Ortransport-default list — previously duplicated acrossserver.New(which mutated its caller's config in place),
buildServeConfig, andderiveServerConfig— is consolidated into a single resolver,
WithDefaults, applied once at the compositionroot (
cli/serve.go). The projection helpers (deriveServerConfig,buildServeConfig)are now pure pass-through, and
server.Newresolves defaults on a copy so it no longermutates its caller. That non-mutation removes a carry-forward gotcha for #5445: the raw,
un-defaulted
cfg.Namenow survives for Cedar authz parity instead of being overwrittenwith the synthetic
toolhive-vmcpdefault.No serialized/CRD/YAML format changes; this is an in-memory-only refactor. The assembled
server's runtime behavior is unchanged — the same defaults are applied, just resolved once
at the edge.
Closes #5444
Type of change
Test plan
task test)task lint-fix)pkg/vmcp/server,pkg/vmcp/core, andpkg/vmcp/clipass cleanly, including under-race; the fulltask testrun is green.derive_test.go(7 tests): verbatim transport-field projection, nil cross-cuttingpropagation (disabled subsystems stay nil), collaborator pass-through by identity for the
core config, raw-
ServerNameuse with nil admission/health defaults, read-only treatmentof the input
cfg, and reflection-based drift guards asserting every field of both outputconfigs is populated.
server_test.go:TestWithDefaultscovers the defaulting matrix (all defaults /explicit-preserved / partial /
Portnever defaulted) andTestWithDefaultsDoesNotMutateInputasserts the resolver does not mutate its argument. The existing
server.Newdefaultingtests are unchanged — it still resolves defaults, now via the shared resolver.
task lint-fixis clean for the changed files; the only flag is a pre-existing gosec G115in
cmd/thv/app/upgrade.go, unrelated.Changes
pkg/vmcp/server/derive.goWithDefaultsresolver andderiveServerConfig/deriveCoreConfig(pure projections)pkg/vmcp/server/server.goserver.Newresolves defaults on a copy viaWithDefaults(no caller mutation)pkg/vmcp/server/serve.gobuildServeConfigis now a pure pass-throughpkg/vmcp/cli/serve.goWithDefaultspkg/vmcp/server/derive_test.gopkg/vmcp/server/server_test.goTestWithDefaults+ no-mutation testpkg/vmcp/server/serve_test.goSessionTTL; drop obsolete defaulting testpkg/vmcp/server/serve_session_test.goSessionTTLDoes this introduce a user-facing change?
No.
Special notes for reviewers
The issue's illustrative code predates the final Phase 1/2 types, so the helpers follow the
actual code rather than the issue's snippet. Intentional divergences:
deriveCoreConfigdrops the issue'sdiscoveryMgrparam.core.Confighas nodiscovery field — the core replaces discovery's aggregation role — so the helper instead
populates
Aggregator/Authz/ServerName/Elicitation/HealthStatusProvider.ServerNameuses the rawcfg.Name(not the transport default) so authorization keys onthe real
VirtualMCPServername rather than the synthetictoolhive-vmcpfallback.Authz/ServerNamevalidation is deferred tocore.New, not defaulted here.The legacy
SessionFactory/OptimizerFactory/OptimizerConfigfields fold into theinjected
*sessionmanager.FactoryConfig(per P2.2 Move SDK hooks + two-phase session creation under Serve #5440).deriveServerConfigtakes that,the pre-built
*health.Monitor(A2), and the sharedBackendRegistryas parametersbecause
server.Configdoes not carry them; only the composition root can build thefactory config (its
ComposerFactorycloses over the core-owned router/backend client).AuthzMiddlewareis intentionally not projected.ServerConfighas no such field —authorization moved to the core admission seam (P1.5 Core admission seam (bounded rewrite) #5438), which derives its authorizer from
the
Authzconfig. The vestigialserver.Config.AuthzMiddlewarefield is kept on purpose;the dead HTTP authz blocks that read it are removed later in P3.2 Reduce server.New body to the wrapper #5445.
Option 1 scope note. This PR now also touches
server.Newandcli/serve.go, which theissue originally scoped as "unchanged" — a deliberate deviation adopting reviewer feedback
(consolidate transport defaulting at the edge).
server.Newkeeps defaulting defensively(via the shared
WithDefaults, not a duplicated list) rather than becoming strictly pure,because full purity would require resolving
SessionTTLat ~30 legacyserver.Newtestsites (a zero TTL panics the session-manager cleanup ticker) and #5445 deletes
server.New's body anyway — so the durable wins (one default list, pure projections, nocaller mutation) land now without throwaway churn.
Design decision — separate config types are intentional (Option 2 declined). A reviewer
asked whether the separate
core.Config/ServerConfigtypes plus derive helpers are worthit versus one shared config (
core.Configis mostly injected collaborators). We are keepingthe split: the two-type boundary is what lets the core be constructed and tested with no
transport concerns, and the drift-guarded derive helpers are the single mapping point.
Collapsing to one shared config would re-couple the layers this epic is decomposing.
This is the first half of Phase 3; it blocks #5445 (P3.2, the
server.Newreduction).Parent story #5432; epic #5419.
🤖 Generated with Claude Code